{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "tags": [
     "parameters"
    ]
   },
   "outputs": [],
   "source": [
    "epochs = 10\n",
    "# Não usamos todo o conjunto de dados para fins de eficiência, mas fique à vontade para aumentar esses números\n",
    "n_train_items = 640\n",
    "n_test_items = 640"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Parte X - Treinamento e Avaliação Seguros no MNIST\n",
    "\n",
    "Ao criar soluções de Aprendizado de Máquina como Serviço (MLaaS), uma empresa pode precisar solicitar acesso a dados de outros parceiros para treinar seu modelo. Na saúde ou nas finanças, o modelo e os dados são extremamente críticos: os parâmetros do modelo são um ativo de negócios, enquanto os dados são dados pessoais que são rigorosamente regulamentados.\n",
    "\n",
    "Nesse contexto, uma solução possível é criptografar o modelo e os dados e treinar o modelo de aprendizado de máquina sobre os valores criptografados. Isso garante que a empresa não acesse os registros médicos dos pacientes, por exemplo, e que os estabelecimentos de saúde não possam observar o modelo para o qual contribuem. Existem vários esquemas de criptografia que permitem a computação sobre dados criptografados, entre os quais a Computação Multipartidária Segura (SMPC - Secure Multipart Computation), a Criptografia Homomórfica (FHE / SHE) e a Criptografia Funcional (FE). Vamos nos concentrar aqui na Computação Multipartidária (que foi introduzida no Tutorial 5), que consiste no compartilhamento de aditivos privados e depende dos protocolos de criptografia SecureNN e SPDZ.\n",
    "\n",
    "A configuração exata deste tutorial é a seguinte: considere que você é o servidor e gostaria de treinar seu modelo em alguns dados mantidos por $n$ workers. O segredo do servidor compartilha seu modelo e envia cada compartilhamento a um worker. Os workers também compartilham seus dados em segredo e os trocam entre eles. Na configuração que iremos estudar, existem 2 workers: alice e bob. Após a troca de ações, cada um deles agora possui uma de suas próprias ações, uma ação do outro worker e uma ação do modelo. A computação agora pode começar a treinar o modelo em particular, usando os protocolos criptográficos apropriados. Depois que o modelo é treinado, todos os compartilhamentos podem ser enviados de volta ao servidor para descriptografá-lo. Isso é ilustrado com a figura a seguir:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "![SMPC Illustration](https://github.com/OpenMined/PySyft/raw/11c85a121a1a136e354945686622ab3731246084/examples/tutorials/material/smpc_illustration.png)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Para dar um exemplo desse processo, vamos assumir que alice e bob mantêm uma parte do conjunto de dados MNIST e vamos treinar um modelo para executar a classificação de dígitos!\n",
    "\n",
    "Autor:\n",
    "- Théo Ryffel - Twitter: [@theoryffel](https://twitter.com/theoryffel) · GitHub: [@LaRiffle](https://github.com/LaRiffle)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 1. Demonstração de treinamento criptografado no MNIST"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Configuração de importações e treinamento"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch\n",
    "import torch.nn as nn\n",
    "import torch.nn.functional as F\n",
    "import torch.optim as optim\n",
    "from torchvision import datasets, transforms\n",
    "\n",
    "import time"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esta classe descreve todos os hiperparâmetros para o treinamento. Observe que todos eles são públicos aqui."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Arguments():\n",
    "    def __init__(self):\n",
    "        self.batch_size = 64\n",
    "        self.test_batch_size = 64\n",
    "        self.epochs = epochs\n",
    "        self.lr = 0.02\n",
    "        self.seed = 1\n",
    "        self.log_interval = 1 # Informações de log em cada lote\n",
    "        self.precision_fractional = 3\n",
    "\n",
    "args = Arguments()\n",
    "\n",
    "_ = torch.manual_seed(args.seed)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aqui estão as importações do PySyft. Nós nos conectamos a dois workers remotos que são chamados `alice` e `bob` e solicitamos outro worker chamado `crypto_provider`, que fornece todas as primitivas criptográficas que precisamos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import syft as sy  # importe a biblioteca PySyft\n",
    "hook = sy.TorchHook(torch)  # conecte o PyTorch para adicionar funcionalidades extras, como Federated e Encrypted Learning\n",
    "\n",
    "# funções de simulação\n",
    "def connect_to_workers(n_workers):\n",
    "    return [\n",
    "        sy.VirtualWorker(hook, id=f\"worker{i+1}\")\n",
    "        for i in range(n_workers)\n",
    "    ]\n",
    "def connect_to_crypto_provider():\n",
    "    return sy.VirtualWorker(hook, id=\"crypto_provider\")\n",
    "\n",
    "workers = connect_to_workers(n_workers=2)\n",
    "crypto_provider = connect_to_crypto_provider()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Obtendo acesso e compartilhamento dados secretos\n",
    "\n",
    "Aqui, estamos usando uma função de utilitário que simula o seguinte comportamento: assumimos que o conjunto de dados MNIST é distribuído em partes, cada uma das quais mantida por um de nossos workers. Os workers então dividem seus dados em lotes e compartilham seus dados em segredo. O objeto final retornado é iterável nesses lotes secretos compartilhados, que chamamos de **carregador de dados privados**. Observe que durante o processo o trabalhador local (assim nós) nunca teve acesso aos dados.\n",
    "\n",
    "Obtemos, como de costume, um conjunto de dados privados de treinamento e teste, e as entradas e os rótulos são compartilhados em segredo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_private_data_loaders(precision_fractional, workers, crypto_provider):\n",
    "    \n",
    "    def one_hot_of(index_tensor):\n",
    "        \"\"\"\n",
    "        Transformar em one hot tensor\n",
    "        \n",
    "        Examplo:\n",
    "            [0, 3, 9]\n",
    "            =>\n",
    "            [[1., 0., 0., 0., 0., 0., 0., 0., 0., 0.],\n",
    "             [0., 0., 0., 1., 0., 0., 0., 0., 0., 0.],\n",
    "             [0., 0., 0., 0., 0., 0., 0., 0., 0., 1.]]\n",
    "            \n",
    "        \"\"\"\n",
    "        onehot_tensor = torch.zeros(*index_tensor.shape, 10) # 10 classes para o MNIST\n",
    "        onehot_tensor = onehot_tensor.scatter(1, index_tensor.view(-1, 1), 1)\n",
    "        return onehot_tensor\n",
    "        \n",
    "    def secret_share(tensor):\n",
    "        \"\"\"\n",
    "        Transformar em precisão fixa and compartilhar secretamente um tensor\n",
    "        \"\"\"\n",
    "        return (\n",
    "            tensor\n",
    "            .fix_precision(precision_fractional=precision_fractional)\n",
    "            .share(*workers, crypto_provider=crypto_provider, requires_grad=True)\n",
    "        )\n",
    "    \n",
    "    transformation = transforms.Compose([\n",
    "        transforms.ToTensor(),\n",
    "        transforms.Normalize((0.1307,), (0.3081,))\n",
    "    ])\n",
    "    \n",
    "    train_loader = torch.utils.data.DataLoader(\n",
    "        datasets.MNIST('../data', train=True, download=True, transform=transformation),\n",
    "        batch_size=args.batch_size\n",
    "    )\n",
    "    \n",
    "    private_train_loader = [\n",
    "        (secret_share(data), secret_share(one_hot_of(target)))\n",
    "        for i, (data, target) in enumerate(train_loader)\n",
    "        if i < n_train_items / args.batch_size\n",
    "    ]\n",
    "    \n",
    "    test_loader = torch.utils.data.DataLoader(\n",
    "        datasets.MNIST('../data', train=False, download=True, transform=transformation),\n",
    "        batch_size=args.test_batch_size\n",
    "    )\n",
    "    \n",
    "    private_test_loader = [\n",
    "        (secret_share(data), secret_share(target.float()))\n",
    "        for i, (data, target) in enumerate(test_loader)\n",
    "        if i < n_test_items / args.test_batch_size\n",
    "    ]\n",
    "    \n",
    "    return private_train_loader, private_test_loader\n",
    "    \n",
    "    \n",
    "private_train_loader, private_test_loader = get_private_data_loaders(\n",
    "    precision_fractional=args.precision_fractional,\n",
    "    workers=workers,\n",
    "    crypto_provider=crypto_provider\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Especificação do modelo\n",
    "\n",
    "Aqui está o modelo que usaremos, é bastante simples, mas [provou ter um desempenho razoavelmente bom no MNIST](https://towardsdatascience.com/handwritten-digit-mnist-pytorch-977b5338e627)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Net(nn.Module):\n",
    "    def __init__(self):\n",
    "        super(Net, self).__init__()\n",
    "        self.fc1 = nn.Linear(28 * 28, 128)\n",
    "        self.fc2 = nn.Linear(128, 64)\n",
    "        self.fc3 = nn.Linear(64, 10)\n",
    "\n",
    "    def forward(self, x):\n",
    "        x = x.view(-1, 28 * 28)\n",
    "        x = F.relu(self.fc1(x))\n",
    "        x = F.relu(self.fc2(x))\n",
    "        x = self.fc3(x)\n",
    "        return x"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Funções de treinamento e teste\n",
    "\n",
    "O treinamento é realizado quase como de costume, a diferença real é que não podemos usar funções de custo como negative log-likelihood (`F.nll_loss` no PyTorch) porque é bastante complicado reproduzir essas funções com o SMPC. Em vez disso, usamos uma função mais simples de erro médio quadrático."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def train(args, model, private_train_loader, optimizer, epoch):\n",
    "    model.train()\n",
    "    for batch_idx, (data, target) in enumerate(private_train_loader): # <-- agora é um conjunto de dados privado\n",
    "        start_time = time.time()\n",
    "        \n",
    "        optimizer.zero_grad()\n",
    "        \n",
    "        output = model(data)\n",
    "        \n",
    "        # loss = F.nll_loss(output, target)  <-- não é possível aqui\n",
    "        batch_size = output.shape[0]\n",
    "        loss = ((output - target)**2).sum().refresh()/batch_size\n",
    "        \n",
    "        loss.backward()\n",
    "        \n",
    "        optimizer.step()\n",
    "\n",
    "        if batch_idx % args.log_interval == 0:\n",
    "            loss = loss.get().float_precision()\n",
    "            print('Train Epoch: {} [{}/{} ({:.0f}%)]\\tLoss: {:.6f}\\tTime: {:.3f}s'.format(\n",
    "                epoch, batch_idx * args.batch_size, len(private_train_loader) * args.batch_size,\n",
    "                100. * batch_idx / len(private_train_loader), loss.item(), time.time() - start_time))\n",
    "            "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "A função de teste não muda!"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def test(args, model, private_test_loader):\n",
    "    model.eval()\n",
    "    test_loss = 0\n",
    "    correct = 0\n",
    "    with torch.no_grad():\n",
    "        for data, target in private_test_loader:\n",
    "            start_time = time.time()\n",
    "            \n",
    "            output = model(data)\n",
    "            pred = output.argmax(dim=1)\n",
    "            correct += pred.eq(target.view_as(pred)).sum()\n",
    "\n",
    "    correct = correct.get().float_precision()\n",
    "    print('\\nTest set: Accuracy: {}/{} ({:.0f}%)\\n'.format(\n",
    "        correct.item(), len(private_test_loader)* args.test_batch_size,\n",
    "        100. * correct.item() / (len(private_test_loader) * args.test_batch_size)))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Vamos iniciar o treinamento!\n",
    "\n",
    "Algumas notas sobre o que está acontecendo aqui. Primeiro, compartilhamos secretamente todos os parâmetros do modelo entre nossos funcionários. Segundo, convertemos os hiperparâmetros do otimizador em precisão fixa. Observe que não precisamos compartilhá-los em segredo porque eles são públicos em nosso contexto, mas como os valores compartilhados em segredo vivem em campos finitos, ainda precisamos movê-los em campos finitos usando `.fix_precision`, para executar operações consistentes como a atualização de peso $W \\leftarrow W - \\alpha * \\Delta W$."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "model = Net()\n",
    "model = model.fix_precision().share(*workers, crypto_provider=crypto_provider, requires_grad=True)\n",
    "\n",
    "optimizer = optim.SGD(model.parameters(), lr=args.lr)\n",
    "optimizer = optimizer.fix_precision() \n",
    "\n",
    "for epoch in range(1, args.epochs + 1):\n",
    "    train(args, model, private_train_loader, optimizer, epoch)\n",
    "    test(args, model, private_test_loader)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aí está você! Você obtém 75% de precisão usando uma pequena fração do conjunto de dados MNIST, usando treinamento 100% criptografado!"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 2. Discussão\n",
    "\n",
    "Vamos dar uma olhada no poder do treinamento criptografado analisando o que acabamos de fazer."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2.1 Tempo de computação\n",
    "\n",
    "A primeira coisa é obviamente o tempo de execução! Como você certamente notou, é muito mais lento que o treinamento em texto simples. Em particular, uma iteração sobre 1 lote de 64 itens leva 3,2s, enquanto apenas 13ms no PyTorch puro. Embora isso possa parecer um bloqueador, lembre-se de que aqui tudo aconteceu remotamente e no mundo criptografado: nenhum item de dados foi divulgado. Mais especificamente, o tempo para processar um item é de 50 ms, o que não é tão ruim assim. A verdadeira questão é analisar quando o treinamento criptografado é necessário e quando apenas a previsão criptografada é suficiente. 50ms para realizar uma previsão é completamente aceitável em um cenário pronto para produção, por exemplo!\n",
    "\n",
    "Um dos principais gargalos é o uso de funções de ativação dispendiosas: a ativação da relu com o SMPC é muito dispendiosa porque utiliza comparação privada e o protocolo SecureNN. Como ilustração, se substituirmos relu por uma ativação quadrática, como é feito em vários trabalhos sobre computação criptografada como CryptoNets, passamos de 3,2s para 1,2s.\n",
    "\n",
    "Como regra geral, a principal idéia a ser retirada é criptografar apenas o necessário, e este tutorial mostra como pode ser simples."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2.2 Backpropagation com SMPC\n",
    "\n",
    "Você pode se perguntar como realizamos atualizações de retropropagação e gradiente, embora estejamos trabalhando com números inteiros em campos finitos. Para isso, desenvolvemos um novo tensor de eixo chamado AutogradTensor. Este tutorial o utilizou intensivamente, embora você talvez não o tenha visto! Vamos verificar isso imprimindo o peso de um modelo:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "model.fc3.bias"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "E um item dos dados:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "first_batch, input_data = 0, 0\n",
    "private_train_loader[first_batch][input_data]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Como você observa, o AutogradTensor está lá! Ele vive entre o wrapper do torch e o FixedPrecisionTensor, que indica que os valores agora estão em campos finitos. O objetivo deste AutogradTensor é armazenar o gráfico de computação quando operações são feitas com valores criptografados. Isso é útil porque, ao chamar para trás a backpropagation, esse AutogradTensor substitui todas as funções anteriores que não são compatíveis com a computação criptografada e indica como calcular esses gradientes. Por exemplo, em relação à multiplicação que é feita usando o truque Beaver triples, não queremos diferenciar ainda mais o fato de que diferenciar uma multiplicação deve ser muito fácil: $\\partial_b (a \\cdot b) = a \\cdot \\partial b$. Aqui está como descrevemos como calcular esses gradientes, por exemplo:\n",
    "\n",
    "```python\n",
    "class MulBackward(GradFunc):\n",
    "    def __init__(self, self_, other):\n",
    "        super().__init__(self, self_, other)\n",
    "        self.self_ = self_\n",
    "        self.other = other\n",
    "\n",
    "    def gradient(self, grad):\n",
    "        grad_self_ = grad * self.other\n",
    "        grad_other = grad * self.self_ if type(self.self_) == type(self.other) else None\n",
    "        return (grad_self_, grad_other)\n",
    "```\n",
    "\n",
    "Você pode dar uma olhada em `tensors/interpreters/gradients.py` se estiver curioso para ver como implementamos mais gradientes.\n",
    "\n",
    "Em termos de grafos de computação, significa que uma cópia do grafo permanece local e que o servidor que coordena a passagem para frente também fornece instruções sobre como fazer a passagem para trás. Essa é uma hipótese completamente válida em nosso cenário."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2.3 Garantias de segurança\n",
    "\n",
    "\n",
    "Por fim, vamos dar algumas dicas sobre a segurança que estamos alcançando aqui: os adversários que estamos considerando aqui são **honestos, mas curiosos**: isso significa que um adversário não pode aprender nada sobre os dados executando este protocolo, mas um adversário mal-intencionado ainda pode se desviar do protocolo e, por exemplo, tentar corromper os compartilhamentos para sabotar a computação. A segurança contra adversários mal-intencionados em tais cálculos SMPC, incluindo comparação privada, ainda é um problema em aberto.\n",
    "\n",
    "Além disso, mesmo que a Computação Multipartidária Segura (SMPC) garanta que os dados de treinamento não foram acessados, muitas ameaças do mundo do texto sem formatação ainda estão presentes aqui. Por exemplo, como você pode fazer uma solicitação ao modelo (no contexto do MLaaS), é possível obter previsões que podem divulgar informações sobre o conjunto de dados de treinamento. Em particular, você não tem proteção contra ataques de associação, um ataque comum a serviços de aprendizado de máquina em que o adversário deseja determinar se um item específico foi usado no conjunto de dados. Além disso, outros ataques, como processos de memorização não intencional (modelos que aprendem recursos específicos sobre um item de dados), inversão ou extração de modelos ainda são possíveis.\n",
    "\n",
    "Uma solução geral que é eficaz para muitas das ameaças mencionadas acima é adicionar Privacidade Diferencial. Ele pode ser combinado com a Computação Multipartidária Segura e pode fornecer garantias de segurança muito interessantes. No momento, estamos trabalhando em várias implementações e esperamos propor um exemplo que combine as duas em breve!"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Conclusão\n",
    "\n",
    "Como você viu, o treinamento de um modelo usando SMPC não é complicado do ponto de vista do código, mesmo usando objetos bastante complexos por debaixo dos panos. Com isso em mente, agora você deve analisar seus casos de uso para ver quando a computação criptografada é necessária para treinamento ou avaliação. Se a computação criptografada for muito mais lenta em geral, também poderá ser usada com cuidado para reduzir a sobrecarga geral da computação.\n",
    "\n",
    "Se você gostou disso e gostaria de participar do movimento de preservação da privacidade, propriedade descentralizada da IA e da cadeia de suprimentos da AI (dados), você pode fazê-lo das seguintes maneiras!\n",
    "\n",
    "### Dê-nos uma estrela em nosso repo do PySyft no GitHub\n",
    "\n",
    "A maneira mais fácil de ajudar nossa comunidade é adicionando uma estrela nos nossos repositórios! Isso ajuda a aumentar a conscientização sobre essas ferramentas legais que estamos construindo.\n",
    "\n",
    "- [Star PySyft](https://github.com/OpenMined/PySyft)\n",
    "\n",
    "### Veja nossos tutoriais no GitHub!\n",
    "\n",
    "Fizemos tutoriais muito bons para entender melhor como deve ser a Aprendizagem Federada e Preservante de Privacidade e como estamos construindo os tijolos para que isso aconteça.\n",
    "\n",
    "- [Confira os tutoriais do PySyft](https://github.com/OpenMined/PySyft/tree/master/examples/tutorials)\n",
    "\n",
    "\n",
    "### Junte-se ao Slack!\n",
    "\n",
    "A melhor maneira de manter-se atualizado sobre os últimos avanços é se juntar à nossa comunidade! Você pode fazer isso preenchendo o formulário em http://slack.openmined.org\n",
    "\n",
    "\n",
    "### Contribua com o projeto!\n",
    "\n",
    "A melhor maneira de contribuir para a nossa comunidade é se tornando um contribuidor do código! A qualquer momento, você pode acessar a página de Issues (problemas) do PySyft no GitHub e filtrar por \"Projetos\". Isso mostrará todas as etiquetas (tags) na parte superior, com uma visão geral de quais projetos você pode participar! Se você não deseja ingressar em um projeto, mas gostaria de codificar um pouco, também pode procurar mais mini-projetos \"independentes\" pesquisando problemas no GitHub marcados como `good first issue`.\n",
    "\n",
    "- [Projetos do PySyft](https://github.com/OpenMined/PySyft/issues?q=is%3Aopen+is%3Aissue+label%3AProject)\n",
    "- [Etiquetados como Good First Issue](https://github.com/OpenMined/PySyft/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)\n",
    "\n",
    "### Doar\n",
    "\n",
    "Se você não tem tempo para contribuir com nossa base de códigos, mas ainda deseja nos apoiar, também pode se tornar um Apoiador em nosso Open Collective. Todas as doações vão para hospedagem na web e outras despesas da comunidade, como hackathons e meetups!\n",
    "\n",
    "- [Página do Open Collective do OpenMined](https://opencollective.com/openmined)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "celltoolbar": "Tags",
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.7.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
